Skip to content

fix(plugin-forms): give each embedded form its own element ids - #3031

Open
eisenbruch wants to merge 3 commits into
emdash-cms:mainfrom
eisenbruch:fix/form-embed-duplicate-ids
Open

fix(plugin-forms): give each embedded form its own element ids#3031
eisenbruch wants to merge 3 commits into
emdash-cms:mainfrom
eisenbruch:fix/form-embed-duplicate-ids

Conversation

@eisenbruch

Copy link
Copy Markdown

What does this PR do?

Fixes duplicate element IDs when the same form is embedded more than once on a page.

fieldId() built IDs from the form ID and the field name alone:

function fieldId(name: string): string {
	return `${formId}-${name}`;
}

Every rendering of a given form therefore produced the same IDs. Each rendering now carries a short per-instance suffix, so labels, inputs and the honeypot stay paired within their own copy.

The honeypot built its ID inline in two places (`${formId}-_hp`) and had the same problem, so it goes through fieldId() now too.

Closes #2909

Verified on a live site

A site embedding its newsletter form three times per page — a rail panel, an inline module and a pop-up — serves two IDs three times each on every page:

duplicate ids: newsletter-email x3, newsletter-interests x3

Clicking the second or third label focuses the first input, which is the reported symptom.

Why this is safe to change

Element IDs are not part of the plugin's public API, and nothing outside the component reads them: fieldId() is used only inside FormEmbed.astro, and the client script scopes its lookups to the form element (form.querySelector(...), [data-error-for="name"]). The one document-wide lookup, in handlePopState, matches on [data-ec-form][data-form-id=...] rather than an element ID, so multi-page navigation is unaffected. No tests or e2e selectors reference the ID shape.

data-form-id deliberately still repeats across copies of the same form — that is the form's identity, not an element identity, and changing it is out of scope here.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable) — see note
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion
  • I have included screenshots below if this PR changes the UI — described instead, see below

Honest notes on the ticks:

  • No test added. packages/plugins/forms/tests covers schemas, the public definition and the client submit response; there is no harness that renders FormEmbed.astro, and adding one for this felt like more surface than the fix warrants. If you would like the component rendered under experimental_AstroContainer — asserting that two renderings of the same form share no element IDs — say so and I will add it.
  • pnpm --filter @emdash-cms/plugin-forms test is 19/19, pnpm lint:json reports nothing for the touched file, and pnpm format is clean.
  • No new strings, so nothing to wrap; no messages.po changes included.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

No screenshot. The change is invisible — identical markup with different id attribute values — and the only site I can reproduce it on is a private pre-launch client site. The observable difference is in the HTML: three copies of a form previously emitted id="newsletter-email" three times, and now emit three distinct IDs, with each <label for> matching the input beside it.

I have not rendered the patched component. The package ships the .astro file as source with no build step, and I could not run it against a realistic page locally. The change is a string suffix and the existing suite passes, but I would rather say that than imply I watched it render.

Field ids were built from the form id and the field name only, so embedding
the same form twice on a page produced duplicate ids: a label focused the
first copy rather than the one beside it, and anything resolving an id reached
the wrong instance. Each rendering now carries a per-instance suffix.

The honeypot built its id inline in two places and had the same problem, so it
goes through fieldId() now as well.
@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 18d5ffa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@emdash-cms/plugin-forms Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right fix for the reported bug. Generating a per-render instance suffix for element IDs correctly prevents duplicate IDs when the same form is embedded multiple times on a page, and routing the honeypot through fieldId() fixes the last hard-coded ID pair. The name attributes (which the server cares about) are unchanged, and the client script scopes its error lookups to [data-error-for="name"] within the form, so submissions and progressive enhancement keep working.

I checked FormEmbed.astro, Form.astro, the client script, the public definition/schema flow, and the submit handler. All label/input pairs, the honeypot, and hidden/file/select/textarea/checkbox inputs now go through fieldId(); radio and checkbox-group options correctly wrap their labels so they don’t need IDs. The changeset is clear, present-tense, and names the affected package and user-visible symptom.

The only gap is the missing regression test. The PR description already offers to add an experimental_AstroContainer test that renders the same form twice and asserts the resulting element IDs are distinct; I think that small test is worth including so this bug can’t quietly regress. No other blockers.

/** Generate an element ID for a field */
/** Suffix that distinguishes this rendering from any other on the same page. The same form is often embedded more
* than once - a sidebar, a footer and a pop-up - and without it every copy repeats the same element ids, so a
* label, an aria-describedby or a password manager resolves to the first copy rather than the one being used. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] A regression test would guard this fix. The PR description mentions rendering the component twice with experimental_AstroContainer and asserting the IDs are distinct — that test is worth adding, since the current suite has no Astro-component coverage and the fix is otherwise only a string suffix that a future refactor could easily drop.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond area/plugins size/S labels Sep 10, 2026
Renders FormEmbed twice through AstroContainer and asserts the id sets are
disjoint, that ids are unique within a rendering, and that every label points
at an input in its own copy. Fails on the previous fieldId() with
"expected [ 'newsletter-email', ...(2) ] to deeply equal []".

The package's vitest config goes through astro's getViteConfig so a .astro
component can be imported, matching packages/core's repro render tests.
@eisenbruch

Copy link
Copy Markdown
Author

Added the regression test you asked for.

tests/form-embed-ids.render.test.ts renders FormEmbed twice through experimental_AstroContainer and asserts three things: the two renderings share no element ids, ids are unique within a rendering, and every <label for> resolves to an input in its own copy.

It fails on the previous fieldId() and passes on this branch:

× two renderings of the same form share no element ids
  AssertionError: expected [ 'newsletter-email', …(2) ] to deeply equal []

One thing worth flagging for review: the package's vitest.config.ts now goes through astro's getViteConfig so a .astro component can be imported at all. That follows packages/core's repro render tests, and the existing include glob and node environment are unchanged — the three existing suites still run and pass (22/22 across 4 files). Happy to move the test to a separate config and script instead if you would rather keep the default one plain.

@github-actions github-actions Bot added size/M review/needs-rereview Author pushed changes since the last review cla: signed and removed size/S review/awaiting-author Reviewed; waiting on the author to respond labels Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/plugins cla: signed review/needs-rereview Author pushed changes since the last review size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-forms: element ids collide when the same form is embedded more than once on a page

1 participant